diff options
Diffstat (limited to 'app/[lng]/pdftron-viewer/page.tsx')
| -rw-r--r-- | app/[lng]/pdftron-viewer/page.tsx | 507 |
1 files changed, 507 insertions, 0 deletions
diff --git a/app/[lng]/pdftron-viewer/page.tsx b/app/[lng]/pdftron-viewer/page.tsx new file mode 100644 index 00000000..bde60a41 --- /dev/null +++ b/app/[lng]/pdftron-viewer/page.tsx @@ -0,0 +1,507 @@ +// app/pdftron-viewer/page.tsx + +"use client" + +import * as React from "react" +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { ArrowLeft, MessageSquare, Download, Upload } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { useSession } from "next-auth/react" +import { useToast } from "@/hooks/use-toast" +import type { WebViewerInstance } from "@pdftron/webviewer" + +// PDFTron 코멘트 타입 정의 +interface PDFTronComment { + id: number + documentReviewId: number + pdftronDocumentId: string + xfdfString: string + annotationData: any + commentSummary?: { + total: number + open: number + resolved: number + rejected: number + deferred: number + byCategory: Record<string, number> + bySeverity: Record<string, number> + byAuthor: Record<string, number> + } + createdBy: number + createdByName?: string + createdByType: "buyer" | "vendor" + createdAt: Date + updatedAt: Date +} + +export default function PDFTronViewerPage() { + const { data: session, status } = useSession() + const searchParams = useSearchParams() + const viewerRef = React.useRef<HTMLDivElement>(null) + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [lastSavedTime, setLastSavedTime] = React.useState<Date | null>(null) + const [isSaving, setIsSaving] = React.useState(false) + const [annotationCount, setAnnotationCount] = React.useState(0) + const { toast } = useToast() + const initialized = React.useRef(false) + const isCancelled = React.useRef(false) + const autoSaveTimerRef = React.useRef<NodeJS.Timeout | null>(null) + const xfdfLoadedRef = React.useRef(false) // XFDF 로딩 완료 여부 추적 + + // URL 파라미터에서 정보 가져오기 + const filePath = searchParams.get('filePath') + const documentId = searchParams.get('documentId') + const documentReviewId = searchParams.get('documentReviewId') + const sessionId = searchParams.get('sessionId') + const documentName = searchParams.get('documentName') + + // PDFTron WebViewer 초기화 - session과 XFDF 모두 준비된 후 실행 + React.useEffect(() => { + if (!initialized.current && viewerRef.current && filePath && session && documentReviewId) { + initialized.current = true + isCancelled.current = false + + // XFDF 먼저 로드한 후 WebViewer 초기화 + loadAndInitializeViewer() + } + + return () => { + if (instance) { + try { + instance.UI.dispose() + } catch (error) { + console.warn("Error disposing viewer:", error) + } + } + isCancelled.current = true + + // 타이머 정리 + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + } + }, [filePath, session, documentReviewId, sessionId]) + + const loadAndInitializeViewer = async () => { + try { + // 1. 먼저 기존 XFDF 로드 + let existingXFDF = "" + try { + const response = await fetch(`/api/pdftron-comments/xfdf?documentReviewId=${documentReviewId}`) + if (response.ok) { + const data = await response.json() + if (data.xfdfString) { + existingXFDF = data.xfdfString + console.log("Loaded existing XFDF successfully") + } + } + } catch (error) { + console.error("Failed to load XFDF:", error) + } + + // 2. WebViewer 초기화 + await initializeWebViewer(existingXFDF) + + } catch (error) { + console.error("Failed to initialize viewer:", error) + setIsLoading(false) + toast({ + title: "Error", + description: "Failed to initialize document viewer", + variant: "destructive" + }) + } + } + + const initializeWebViewer = async (existingXFDF: string) => { + try { + console.log("Starting WebViewer initialization...") + console.log("File path:", filePath) + console.log("Current session:", session) + console.log("Has existing XFDF:", !!existingXFDF) + + // 동적 import 사용 + const { default: WebViewer } = await import("@pdftron/webviewer") + + if (isCancelled.current || !viewerRef.current) { + console.log("WebViewer initialization cancelled") + return + } + + // WebViewer 인스턴스 생성 + const webviewerInstance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_LICENSE_KEY || process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + initialDoc: filePath!, + }, + viewerRef.current + ) + + if (isCancelled.current) { + console.log("WebViewer initialization cancelled after creation") + return + } + + setInstance(webviewerInstance) + + if (!webviewerInstance.Core) { + console.error("WebViewer Core is not available") + setIsLoading(false) + return + } + + const { documentViewer, annotationManager, Annotations } = webviewerInstance.Core + + // 현재 사용자 설정 + const currentUser = session?.user?.email || session?.user?.name || 'Anonymous' + console.log("Setting current user:", currentUser) + annotationManager.setCurrentUser(currentUser) + + // 권한 설정 - 자기 annotation만 수정/삭제 가능 + annotationManager.setPermissionCheckCallback((author: string, annotation: any) => { + // 자기가 만든 annotation만 수정 가능 + return author === currentUser + }) + + // 문서 로드 완료 시 + documentViewer.addEventListener('documentLoaded', async () => { + console.log("Document loaded successfully") + setIsLoading(false) + + console.log(existingXFDF) + + // 기존 XFDF 적용 + if (existingXFDF && !xfdfLoadedRef.current) { + console.log(existingXFDF, "existingXFDF") + + try { + await annotationManager.importAnnotations(existingXFDF) + xfdfLoadedRef.current = true + console.log("Imported existing annotations from XFDF") + + // 초기 annotation 수 설정 + const annotations = annotationManager.getAnnotationsList() + setAnnotationCount(annotations.length) + + // 마지막 저장 시간 설정 + setLastSavedTime(new Date()) + } catch (error) { + console.error("Failed to import XFDF:", error) + toast({ + title: "Warning", + description: "Failed to load existing annotations", + variant: "destructive" + }) + } + } + + // UI 설정 (1초 지연) + setTimeout(() => { + setupUI() + }, 1000) + }) + + // UI 설정 함수 + const setupUI = async () => { + try { + console.log("Setting up UI features...") + + // Review 모드 annotation 도구 활성화 + try { + // 주석 도구 활성화 + webviewerInstance.UI.enableElements(['highlightToolButton']) + webviewerInstance.UI.enableElements(['stickyToolButton']) + webviewerInstance.UI.enableElements(['freeTextToolButton']) + webviewerInstance.UI.enableElements(['underlineToolButton']) + webviewerInstance.UI.enableElements(['strikeoutToolButton']) + webviewerInstance.UI.enableElements(['squigglyToolButton']) + + // 노트 패널 열기 + webviewerInstance.UI.openElements(['notesPanel']) + } catch (e) { + console.log("Could not enable annotation tools:", e) + } + + // 커스텀 이벤트 리스너 설정 + setupAnnotationListeners() + } catch (error) { + console.error("Error setting up UI:", error) + } + } + + // Annotation 이벤트 리스너 설정 + const setupAnnotationListeners = () => { + // 자동 저장 함수 + const handleAutoSave = async () => { + if (!documentReviewId) { + console.log("No documentReviewId, skipping auto-save") + return + } + + // 이미 저장 중이면 스킵 + if (isSaving) { + console.log("Already saving, skipping...") + return + } + + setIsSaving(true) + + try { + const xfdfString = await annotationManager.exportAnnotations() + + // Annotation 요약 정보 생성 + const annotations = annotationManager.getAnnotationsList() + const summary = { + total: annotations.length, + open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length, + resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length, + rejected: annotations.filter((a: any) => a.getCustomData('status') === 'rejected').length, + deferred: annotations.filter((a: any) => a.getCustomData('status') === 'deferred').length, + byCategory: {} as Record<string, number>, + bySeverity: {} as Record<string, number>, + byAuthor: {} as Record<string, number> + } + + annotations.forEach((annotation: any) => { + const category = annotation.getCustomData('category') || 'general' + const severity = annotation.getCustomData('severity') || 'minor' + const author = annotation.Author || 'Anonymous' + + summary.byCategory[category] = (summary.byCategory[category] || 0) + 1 + summary.bySeverity[severity] = (summary.bySeverity[severity] || 0) + 1 + summary.byAuthor[author] = (summary.byAuthor[author] || 0) + 1 + }) + + // 서버에 저장 + const response = await fetch('/api/pdftron-comments/xfdf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + documentReviewId: parseInt(documentReviewId), + sessionId: sessionId ? parseInt(sessionId) :0, + pdftronDocumentId: documentId, + xfdfString: xfdfString, + commentSummary: summary, + createdByType: 'buyer' + }) + }) + + if (response.ok) { + setLastSavedTime(new Date()) + setAnnotationCount(annotations.length) + console.log("Auto-save successful") + } else { + console.error("Auto-save failed") + toast({ + title: "Error", + description: "Failed to save annotations", + variant: "destructive" + }) + } + } catch (error) { + console.error("Auto-save error:", error) + toast({ + title: "Error", + description: "Failed to save annotations", + variant: "destructive" + }) + } finally { + setIsSaving(false) + } + } + + // Annotation 변경 감지 + annotationManager.addEventListener('annotationChanged', (annotations: any[], action: string) => { + if (action === 'add' || action === 'modify' || action === 'delete') { + // 새 annotation에 기본 메타데이터 추가 + if (action === 'add') { + annotations.forEach(annotation => { + if (!annotation.getCustomData('category')) { + annotation.setCustomData('category', 'general') + annotation.setCustomData('severity', 'minor') + annotation.setCustomData('status', 'open') + annotation.setCustomData('createdBy', session?.user?.id || '') + annotation.setCustomData('createdByType', 'buyer') + annotation.setCustomData('createdAt', new Date().toISOString()) + + // 기본 색상 설정 (minor = yellow) + try { + if (Annotations) { + annotation.Color = new Annotations.Color(250, 204, 21) + } + } catch (e) { + console.log("Could not set annotation color") + } + } + }) + } + + // 자동 저장 - 2초 디바운싱 + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + autoSaveTimerRef.current = setTimeout(() => { + console.log("Auto-saving annotations...") + handleAutoSave() + }, 2000) + } + }) + + // 코멘트 변경 감지 + annotationManager.addEventListener('annotationCommentsChanged', () => { + // 자동 저장 - 1.5초 디바운싱 + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + autoSaveTimerRef.current = setTimeout(() => { + console.log("Auto-saving comments...") + handleAutoSave() + }, 1500) + }) + + // Annotation 선택 시 기본값 설정 + annotationManager.addEventListener('annotationSelected', (annotations: any, action: string) => { + if (annotations && annotations.length > 0) { + const annotation = annotations[0] + + // 기본 커스텀 데이터 설정 + if (!annotation.getCustomData('category')) { + annotation.setCustomData('category', 'general') + annotation.setCustomData('severity', 'minor') + annotation.setCustomData('status', 'open') + annotation.setCustomData('createdBy', session?.user?.id || '') + annotation.setCustomData('createdByType', 'buyer') + annotation.setCustomData('createdAt', new Date().toISOString()) + } + } + }) + } + + } catch (error) { + console.error("WebViewer initialization failed:", error) + setIsLoading(false) + toast({ + title: "Error", + description: "Failed to initialize document viewer", + variant: "destructive" + }) + } + } + + + + // 통계 정보 가져오기 + const getAnnotationStats = () => { + if (!instance) return null + + const { annotationManager } = instance.Core + const annotations = annotationManager.getAnnotationsList() + + return { + total: annotations.length, + open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length, + resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length + } + } + + // 시간 포맷팅 + const formatLastSaved = () => { + if (!lastSavedTime) return null + + const now = new Date() + const diff = Math.floor((now.getTime() - lastSavedTime.getTime()) / 1000) + + if (diff < 60) return "Just saved" + if (diff < 3600) return `Saved ${Math.floor(diff / 60)} min ago` + if (diff < 86400) return `Saved ${Math.floor(diff / 3600)} hours ago` + return `Saved ${Math.floor(diff / 86400)} days ago` + } + + const stats = getAnnotationStats() + const lastSavedText = formatLastSaved() + + return ( + <div className="flex flex-col h-screen overflow-hidden"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b bg-background flex-shrink-0"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="sm" + onClick={() => window.close()} + > + <ArrowLeft className="h-4 w-4 mr-2" /> + Back + </Button> + <div> + <h1 className="text-lg font-semibold">{documentName || 'Document Viewer'}</h1> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <span>Review Mode</span> + <span>•</span> + <span>User: {session?.user?.email || session?.user?.name || 'Loading...'}</span> + {stats && stats.total > 0 && ( + <> + <span>•</span> + <Badge variant="outline"> + <MessageSquare className="h-3 w-3 mr-1" /> + {stats.open} open / {stats.total} total + </Badge> + </> + )} + {isSaving && ( + <> + <span>•</span> + <Badge variant="outline" className="text-blue-600 border-blue-600"> + <div className="animate-pulse">Auto-saving...</div> + </Badge> + </> + )} + {!isSaving && lastSavedText && ( + <> + <span>•</span> + <Badge variant="outline" className="text-green-600 border-green-600"> + ✓ {lastSavedText} + </Badge> + </> + )} + </div> + </div> + </div> + + </div> + + {/* PDFTron Viewer */} + <div className="flex-1 relative overflow-hidden"> + {(isLoading || status === "loading") && ( + <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div> + <p className="text-sm text-muted-foreground"> + {status === "loading" ? "Loading session..." : "Loading document..."} + </p> + <p className="text-xs text-muted-foreground mt-1"> + Initializing PDFTron viewer... + </p> + </div> + </div> + )} + <div + ref={viewerRef} + className="h-full w-full" + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }} + /> + </div> + </div> + ) +}
\ No newline at end of file |
